Skip to content

fix(account): preserve then rebuild an unreadable account store - #261

Merged
Pixnop merged 2 commits into
devfrom
fix/issue-259-account-store-corruption-refusal
Aug 28, 2026
Merged

fix(account): preserve then rebuild an unreadable account store#261
Pixnop merged 2 commits into
devfrom
fix/issue-259-account-store-corruption-refusal

Conversation

@Zaldaryon

Copy link
Copy Markdown
Collaborator

Summary

An undecryptable or malformed account-secrets store used to be indistinguishable from an absent one: readAccounts caught every failure the same way and cached an empty map either way, so the next login silently overwrote the file, discarding every other saved account's session, not just the one being saved. This makes saveAccountSecrets preserve the unreadable bytes once, then rebuild the store around the login that is actually happening, and tells the caller which one occurred.

Observed problem

Filed as #259 after review of PR #253: "An undecryptable store now costs every saved account its session rather than the one the old store held. readAccounts caches an empty map on any failure and the next saveAccountSecrets writes that map back over the file. Your comment in writeAccounts says exactly this and I think the call is right given the backup, but it is worth a follow-up issue rather than only a comment, because the blast radius genuinely grew."

At single-account scale, an undecryptable store cost the one account it held. Since multi-account, the same code path costs every saved account on the device in one shot, triggered by any single player logging back in.

Design decision

The issue itself named the crux: "letting a re-login for one account fail outright when a housemate's store is unreadable is its own UX question." Two options existed, refuse the write or preserve and proceed. This takes preserve and proceed, for one reason that settles it: the sessions in an unreadable file are already lost the instant it stops decrypting. Refusing the write does not bring any of them back, it only leaves the launcher permanently unable to save any account at all, with nothing in the app to clear the dead file for it. A copy-aside preserves exactly the same bytes a refusal would, while still letting the player's own deliberate login succeed, matching this codebase's own precedent in adoptRefreshedSession (gameHandlers.ts): a storage problem must not block the action the player actually asked for.

The one place refusal is correct is narrower: when the unreadable file cannot even be copied aside (most likely a permissions problem). There, proceeding would destroy bytes rather than merely fail to read them, so that case throws instead of writing anything.

Fix

src/ipc/accountStore.ts:

  • readStore() replaces the old readAccounts() caching logic, returning { accounts, unreadable }. A genuinely absent file (ENOENT) is not unreadable, it is the ordinary no-accounts-yet case and behaves exactly as before. Anything else that keeps the read from succeeding (wrong version, bad JSON, decrypt failure, a decrypted payload with no accounts array) sets unreadable: true. A file holding one entry parseStoredSecretsById itself drops, beside other good entries, is not corruption: that is still a store worth writing to.
  • preserveUnreadableStore() copies the current store file to a new path, account-secrets.unreadable.bak.json, with overwrite: false so the first snapshot survives repeated corruption events. Deliberately a separate file from the existing account-secrets.pre-migration.bak.json: those are two different events (an old-format file being upgraded, versus a current-format file that stopped decrypting), and sharing one path would let whichever happens second silently erase the other's snapshot.
  • AccountStoreUnreadableError is thrown when the copy itself fails, the one case where proceeding would destroy something.
  • saveAccountSecrets now returns a typed AccountSaveOutcome, "saved" or "saved-after-rebuild", instead of void.

src/global.d.ts: AccountLoginResult gets a new status, session-store-unreadable (the credentials were accepted, but nothing could be saved), and an optional storeRebuilt flag on success. Not a separate success status: the login did succeed, and a separate status would make every status === "success" check silently drop the account.

src/ipc/handlers/accountHandlers.ts's LOGIN handler: a rebuild logs a warning and sets storeRebuilt: true on the success result; an AccountStoreUnreadableError resolves session-store-unreadable instead of falling into the generic "Login failed" throw, the same honesty this file already gives unexpected-response for a different failure: the credentials were never actually the problem.

src/renderer/src/components/ui/SessionButton.tsx: a rebuild shows a warning toast naming what happened; session-store-unreadable shows its own error, distinct from "invalid email or password". Two new en-US.json strings.

src/ipc/handlers/gameHandlers.ts's adoptRefreshedSession logs the rebuild too, but needed no behavior change: it already treats a storage failure as non-fatal to the launch, matching the design principle this fix leans on.

Regression proof

tests/ipc/accountStore.test.ts gets a new describe block, 8 cases: bytes preserved byte-for-byte across an undecryptable file, a non-JSON file, and a future-version file; a file holding only a dropped entry treated as readable, not rebuilt; the first snapshot kept across a second corruption event; no collision with the pre-migration backup; and the copy-failure refusal (Linux-only, skipped as root). tests/ipc/accountHandlers.test.ts gets 2 new cases for the wire status and the storeRebuilt flag. tests/ipc/accountLoginOutcome.test.ts gets one for sessionStoreUnreadableResult. A new tests/renderer-dom/sessionButtonStoreRebuilt.test.tsx (3 cases) covers the toasts end to end. tests/ipc/gameHandlers.test.ts and tests/ipc/configManager.test.ts needed their saveAccountSecrets mocks updated to resolve the new return type.

Verified the harness catches a real regression: reverted saveAccountSecrets to the old always-overwrite version, ran tests/ipc/accountStore.test.ts, and 5 of the 8 new cases went red exactly as expected, then reverted and confirmed the file diff was clean.

Testing

  • npm run typecheck: passes, all three projects.
  • npm run lint:ci: 0 errors, 15 pre-existing warnings.
  • npm run format:check: passes.
  • npm run test:coverage: 130 files, 1,583 passed, 2 skipped. Coverage 92.47% statements, 89.63% branches, 91.5% functions, 94.01% lines, all at or above the vitest.config.ts floor.
  • npm run build:unpack: passes on Linux x64.

Base branch

This PR targets feat/issue-238-multi-account (PR #253), not dev, for the same reason PR #260 targets fix/issue-248-vsl-link-palette: the multi-account account store (readAccounts, writeAccounts, saveAccountSecrets keyed by playerUid) this fix is about does not exist on dev yet, only on #253's branch. This should be retargeted to dev (or rebased and reopened) once #253 merges; until then it is a stack, not an independent change.

Limitations

Not verified against a real OS keychain, same limitation as #253 itself: the encryption layer is faked in tests, and the preserveUnreadableStore copy-failure case was only exercised via a chmod'd directory on Linux, not a real permissions failure on Windows or macOS.

Related issues

Fixes #259.

@Pixnop Pixnop left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Reviewed at 5f20182, sitting on top of #253's 4b9f41d. Gates on that head: typecheck clean across all three projects, lint 0 errors and the same 15 warnings, format check clean, test:coverage green at 130 files, 1,583 passed, 2 skipped, statements 92.47, branches 89.63, functions 91.5, lines 94.01, all above the floors.

The design call is right and the reasoning behind it holds. Sessions in a file that stopped decrypting are gone whether or not you write over them, so refusing the write recovers nothing and costs the player a launcher that can never save an account again, with no way to clear the dead file from inside the app. Copying aside and rebuilding preserves the same bytes a refusal would while letting the player's own deliberate login succeed, and the narrow throw when the copy itself fails is in the right place: that really is the only case where proceeding destroys rather than merely fails to read.

Locked keyring versus corrupted file: the code gets it right, nothing pins it. Blocking.

This is the distinction the whole fix turns on, and it is handled well. readStore returns early on a failed assertSecureStorage with unreadable: false, and deliberately does not cache, so a keyring that unlocks later still reaches the real file. writeAccounts asserts the same thing before it could overwrite anything, so a locked keyring cannot reach the rebuild at all. Exactly the split I asked about.

No test holds it there. Flipping that early return to unreadable: true leaves all 130 files and 1,583 tests green.

What that mutation does in the field is worse than it first looks. A player with a perfectly good store and a locked keyring logs in, preserveUnreadableStore copies their intact secrets file to account-secrets.unreadable.bak.json, then writeAccounts throws and the login fails anyway. They are left with a second copy of their encrypted store on disk, and because the snapshot uses overwrite: false, that stale copy permanently occupies the slot a genuine corruption event would have needed later. The recovery path this PR builds gets poisoned by the first locked-keyring login.

One test closes it: an intact v2 store on disk, encryptionAvailable false, assert saveAccountSecrets rejects, assert account-secrets.unreadable.bak.json was never created, and assert the store file is byte-for-byte what it was. The two existing "refuses to write when..." cases are close but both start from no store file at all, so neither can catch this.

The rest checks out

I ran your own mutation to confirm the harness. Reverting saveAccountSecrets to the unconditional writeAccounts turns exactly 5 of the new cases red, matching what you reported. The eight cases are well chosen, and "treats a store whose entries it merely dropped as readable, not unreadable" is the one that keeps this from firing on the ordinary partial-loss case, which is the failure mode I would have expected a fix like this to introduce.

Secrets hygiene is clean. Nothing decrypted reaches a log: the rebuild warning in accountHandlers.ts and the one in adoptRefreshedSession both say that a rebuild happened, never what was in it. storeRebuilt crosses the bridge as a boolean and session-store-unreadable as a status string, neither carrying anything out of the store. preserveUnreadableStore uses fse.copy, which chmods the destination to the source's mode, so the 0600 on account-secrets.json follows the snapshot. Worth an assertion in the test above while you are in there, since that property comes from fs-extra rather than from this file and could change under you without anything here noticing.

Keeping session-store-unreadable off the success union while storeRebuilt rides on it is the right split. The account never reaches config when nothing could be saved, which avoids a config entry with no secrets behind it, and SessionButton returning early on that status is consistent with it.

One non-blocking thought on first-snapshot-wins

The argument in the comment, that the earliest unreadable file is the one most likely to still hold every account ever saved, runs the other way about as often. Corruption hits when one account is saved, the snapshot is taken, the store rebuilds and grows to four accounts, corruption hits again, and the second snapshot is skipped because the first is there. The four-account bytes are the ones destroyed. For genuinely undecryptable bytes that is academic either way, but the version-mismatch case is not: a store written by a newer build really is recoverable by that build, and that is where losing the later snapshot costs something real.

Nothing to change now. If you keep the current policy, it is worth saying in the comment that the snapshot is deliberately one-shot and the trade is accepted, rather than resting it on a "most likely" that does not always hold.

Related, for a follow-up rather than here: nothing in the app ever surfaces or clears account-secrets.unreadable.bak.json, so it sits there indefinitely and silently blocks the next snapshot. The toast is honest about the other accounts needing to log in again, but there is no path back to the preserved bytes and no way to free the slot.

Merge order

These two interlock, so worth being explicit.

#253 on its own still overwrites a v1 store that decrypts wrong with no snapshot, because adoptLegacySingleAccountSecrets only takes its backup after a successful decrypt. This PR is what closes that: readStore classifies a v1 file as unreadable on the version check, so the next login preserves it rather than flattening it. It also ends #253's re-key retry cleanly, since the rebuild leaves a v2 file and adoptLegacySingleAccountSecrets no-ops from then on. There is no conflict between them, because #253 carries no competing rebuild logic, only the comment in writeAccounts acknowledging the hole, which this PR replaces outright.

So #253 first, then this. I would prefer merging this into feat/issue-238-multi-account and taking the pair to dev as one, so dev never carries a build where an unreadable store gets overwritten without a snapshot. Merging #253 to dev and retargeting this also works. Do not land this on dev ahead of #253.

Requesting changes for the locked-keyring test.

Zaldaryon added a commit that referenced this pull request Aug 27, 2026
removeAccountSecrets returned true when the account was absent, including when
it was absent only because the store could not be read (a locked keyring, or
bytes that stopped decrypting). The renderer took that as success, dropped the
account from config, and told the player it was gone while its session sat on
disk under a uid nothing named any more. It now returns readStore().unreadable
=> false in that case, so the renderer keeps the account and shows the store
error instead. No rebuild: only saveAccountSecrets does that.

Adds the locked-keyring regression test #261's review asked for: an intact v2
store plus an unavailable keyring must leave the file byte-for-byte, at its
original mode, and must not create the one-shot unreadable snapshot. Reworks
the preserveUnreadableStore comment to state the first-snapshot-wins trade is
deliberate and accepted rather than resting it on a 'most likely'.

PR #261 review.
@Zaldaryon
Zaldaryon force-pushed the fix/issue-259-account-store-corruption-refusal branch from 5f20182 to b4accd9 Compare August 27, 2026 22:43
@Zaldaryon

Copy link
Copy Markdown
Collaborator Author

Rebased onto feat/issue-238-multi-account's current head (35c4304), which now carries #253's merge of dev and the atomic-write work that came with it. One conflict, in writeAccounts: dev had already swapped that function's manual temp-then-move for writeJsonAtomic, so the rebase keeps dev's version and drops this branch's copy of the old block. Everything else replayed clean.

The locked-keyring test is in. tests/ipc/accountStore.test.ts gets "does not snapshot or touch an intact store when only the keyring is locked": two accounts saved, then encryptionAvailable flipped off, then saveAccountSecrets for a third. It asserts the save rejects, that account-secrets.unreadable.bak.json was never created, and that the real store is byte-for-byte what it was, at the mode it had. Flipping readStore's early unreadable: false to true turns exactly that test red, which is the split it pins: a locked keyring is not a corruption event and must not burn the one-shot snapshot slot.

The removeAccountSecrets hole you raised on #253 was not actually closed on this branch as it stood. The code returned true for an account absent from an unreadable store and left a comment saying so, which is the same false success: the renderer takes true, drops the account from config, and toasts "Removed {name}" while the session sits in the bytes that stopped decrypting under a uid nothing names any more. It now reads through readStore() and returns !store.unreadable when the account is not in the map, so an unreadable store gives false, and SessionButton's existing if (!removed) path shows the error toast and keeps the account. It never rebuilds the file: only saveAccountSecrets, for a login the player actually asked for, does that. Mutation check: reverting that line to return true turns the new removeAccountSecrets test red.

On the first-snapshot-wins note, I kept the policy and rewrote the comment to own the trade rather than rest it on a "most likely": a second corruption event after the store has rebuilt and grown can hold more than the kept snapshot, and for a version-mismatch file that is a real loss, but a single predictable recovery file beats an unbounded pile of them, and nothing surfaces or clears these yet anyway. That last part (no path back to the preserved bytes, no way to free the slot) is the follow-up you flagged; it wants its own issue off #259 rather than more code here.

The 0600-follows-the-snapshot property you asked about is now asserted in the locked-keyring test above (statSync(...).mode & 0o777), since it comes from fse.copy rather than from this file.

Gates on the rebased head, local, Node 24.15.0, WSL: npm run typecheck passes all three projects, npm run lint:ci 0 errors and the same 15 warnings dev carries, npm run format:check passes, npm run test:coverage gives 137 files, 1,629 passed, 2 skipped, 0 failed, coverage 92.6% statements, 89.84% branches, 92.03% functions, 94.05% lines, all above the floors, and npm run build:unpack passes on Linux x64.

Still a draft, still stacked: #253 first, then this retargets to dev.

@Zaldaryon
Zaldaryon requested a review from Pixnop August 27, 2026 22:46

@Pixnop Pixnop left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Approving at b4accd9.

The locked-keyring test does what it needed to do. Flipping the readStore early return to unreadable: true turns "does not snapshot or touch an intact store when only the keyring is locked" red and nothing else, 1 failed out of 1,631. That mutation survived every test last round, and it is the one that mattered most here: a locked keyring is a transient, ordinary condition, and treating it as corruption would copy a perfectly intact store into the single overwrite: false snapshot slot and then fail the login anyway, burning the one recovery file before a real corruption ever needs it. The test asserts the three things that actually pin it, no snapshot file, the store byte-for-byte, and the mode unchanged, so a partial rewrite that preserves only the bytes still gets caught.

Your own mutation still holds too. Collapsing saveAccountSecrets back to an unconditional writeAccounts fails 5 of the preserve tests: the undecryptable case, the not-JSON case, the unknown-version case, the pre-migration backup collision and the copy-failure refusal, 5 out of 1,631.

b4accd9 also closes the removeAccountSecrets note I left on #253, and it closes it the right way. Returning !store.unreadable when the delete finds nothing means a locked keyring or a file that stopped decrypting reports failure instead of telling the player an account was removed while its session sits on disk under a uid nothing names any more, and it gets there without giving removal a rebuild path of its own. The reworked preserveUnreadableStore comment reads better than the old one as well, since it states the first-snapshot-wins trade as a decision with a known cost rather than resting it on a likelihood.

Gates on this head: typecheck clean on all three projects, lint:ci at 0 errors with the same 15 warnings, format:check clean, and test:coverage at 137 files, 1,629 passed and 2 skipped, with statements 92.6, branches 89.84, functions 92.03 and lines 94.05, all above the floors.

Merging #253 first, then retargeting this to dev and taking it straight after, which keeps the ordering these reviews have been assuming throughout.

Base automatically changed from feat/issue-238-multi-account to dev August 28, 2026 11:58
Fixes #259.

readAccounts caught every read failure the same way, absent file or
undecryptable one, and cached an empty map either way. saveAccountSecrets
then wrote that empty map back over whatever was on disk. At
single-account scale that cost the one account the old store held; since
multi-account (#238) it costs every saved account's session in one shot,
the instant any player logs back in.

The sessions in an unreadable file are already gone the moment it stops
decrypting: refusing the write recovers none of them, it only leaves the
launcher unable to save any account ever again, with nothing in the app
to clear the dead file. So this preserves the unreadable bytes once
(preserveUnreadableStore, mirroring the existing pre-migration backup
convention but under its own path so the two events can never collide)
and rebuilds the store around the account logging in now, matching this
codebase's own adoptRefreshedSession precedent: a storage problem must
not block the player's own deliberate action. The one exception is when
the bytes cannot even be copied aside (a permissions problem, most
likely); proceeding there really would destroy something, so it throws
AccountStoreUnreadableError instead.

readStore now distinguishes an absent file (the ordinary no-accounts-yet
case, unchanged) from a present-but-unreadable one via a new `unreadable`
flag, which only saveAccountSecrets reads; every other caller still just
wants the map. saveAccountSecrets returns a typed AccountSaveOutcome
("saved" or "saved-after-rebuild") instead of void.

Wired into LOGIN: a rebuild flags the success result with
`storeRebuilt: true` rather than a status of its own, since the login did
succeed and a separate status would make every `status === "success"`
check silently drop the account. The narrow copy-failure case gets its
own AccountLoginResult status, `session-store-unreadable`, the same
honesty this file already gives `unexpected-response`: the credentials
were fine, only the local save failed. SessionButton shows a warning
toast on a rebuild and a distinct error on the unreadable case, neither
collapsing into "invalid email or password". adoptRefreshedSession
(gameHandlers.ts) logs the rebuild but needed no behavior change, since
it already treats a storage failure as non-fatal to the launch.

8 new tests in accountStore.test.ts cover the rebuild path: bytes
preserved byte-for-byte, a dropped-entry file still treated as readable
(not rebuilt), the first snapshot kept across repeated corruption, no
collision with the pre-migration backup, and the copy-failure refusal.
2 new tests in accountHandlers.test.ts, 1 in accountLoginOutcome.test.ts,
and a new sessionButtonStoreRebuilt.test.tsx cover the wire status and
the UI. Verified the harness catches a real regression: reverting
saveAccountSecrets to the old always-overwrite version turned 5 of the
new accountStore tests red, then reverted cleanly.
removeAccountSecrets returned true when the account was absent, including when
it was absent only because the store could not be read (a locked keyring, or
bytes that stopped decrypting). The renderer took that as success, dropped the
account from config, and told the player it was gone while its session sat on
disk under a uid nothing named any more. It now returns readStore().unreadable
=> false in that case, so the renderer keeps the account and shows the store
error instead. No rebuild: only saveAccountSecrets does that.

Adds the locked-keyring regression test #261's review asked for: an intact v2
store plus an unavailable keyring must leave the file byte-for-byte, at its
original mode, and must not create the one-shot unreadable snapshot. Reworks
the preserveUnreadableStore comment to state the first-snapshot-wins trade is
deliberate and accepted rather than resting it on a 'most likely'.

PR #261 review.
@Pixnop
Pixnop force-pushed the fix/issue-259-account-store-corruption-refusal branch from b4accd9 to 1f2dd31 Compare August 28, 2026 11:59
@Pixnop

Pixnop commented Aug 28, 2026

Copy link
Copy Markdown
Contributor

Note on the commit that landed after my approval, since it is mine and not the author's.

I approved at b4accd9, merged #253, and then found that this branch could not go onto dev as it stood. #253 was squash-merged, so the merge base here stayed at 09f9c45 and git saw both sides rewriting the same regions of accountStore.ts, accountHandlers.ts and gameHandlers.ts. GitHub reported the branch mergeable at first only because it had not recomputed after the retarget; once it did, the state was dirty. A local merge-tree was worse than a plain conflict: the auto-resolution produced a tree missing 130 lines of this PR's own change, so a merge that appeared to succeed would have landed a partial version of the fix.

So I rebased the two commits onto the new dev and force-pushed, b4accd9 becoming 1f2dd31. Nothing about the content moved: git diff b4accd9 1f2dd31 is empty, the trees are identical. The only thing that changed is the ancestry, and the PR now shows its own 13 files against dev instead of dragging #253's diff behind it.

Re-verified on 1f2dd31 rather than assuming the rebase was harmless. Flipping the readStore keyring early return to unreadable: true still turns "does not snapshot or touch an intact store when only the keyring is locked" red and nothing else, 1 failed out of 1,631. Gates: typecheck clean on all three projects, lint:ci at 0 errors with the same 15 warnings dev carries, format:check clean, test:coverage at 137 files with 1,629 passed and 2 skipped, statements 92.58, branches 89.8, functions 92.03 and lines 94.05, all above the floors. CI is green on this head across build, lint, test, typecheck and sonarcloud.

One thing worth recording that has nothing to do with this PR: on one of those coverage runs tests/renderer-dom/taskManagerFlows.test.tsx failed with "useTaskContext must be used within an TaskProvider", then passed on its own and passed again on a full re-run. That file is untouched here, so it is a pre-existing flake in the task manager DOM suite rather than anything this change caused. Flagging it so it is written down somewhere, not asking you to deal with it.

Approval stands. Merging this now.

@Pixnop

Pixnop commented Aug 28, 2026

Copy link
Copy Markdown
Contributor

Taking this out of draft, since the condition the description set for that is now met: "This should be retargeted to dev (or rebased and reopened) once #253 merges; until then it is a stack, not an independent change." #253 landed as b401af7 and this is now rebased onto dev carrying only its own two commits, so it is an independent change rather than a stack.

@Pixnop
Pixnop marked this pull request as ready for review August 28, 2026 12:05
@Pixnop
Pixnop merged commit a3044bb into dev Aug 28, 2026
7 checks passed
@Pixnop
Pixnop deleted the fix/issue-259-account-store-corruption-refusal branch August 28, 2026 12:05
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

2 participants